2 design principles
Core principles that guide all feature development in the TC Portal, based on DHH (David Heinemeier Hansson) and Taylor Otwell’s philosophies for building exceptional monolithic applications.
DHH Principles
1. The Majestic Monolith
Philosophy: Build integrated, cohesive applications. No microservices unless absolutely necessary.
In Practice:
- All features live within the main Laravel application
- Use domain-driven design folders, not separate services
- Share database, leverage transactions
- Use jobs for async work, not separate services
Example:
✅ GOOD: /domain/Funding/Actions/ProcessRolloverAction.php❌ BAD: /microservices/rollover-service/2. Convention Over Configuration
Philosophy: Follow established patterns. Reduce decision fatigue.
In Practice:
- Use Laravel conventions (migrations, models, controllers)
- Follow existing domain patterns in the codebase
- Use standard naming (FundingRollover, not FundingCarryover)
- Leverage Laravel’s magic (relationships, scopes, accessors)
Example:
// ✅ GOOD: Follows Laravel conventionsclass FundingRollover extends Model{ public function sourceFunding(): BelongsTo { return $this->belongsTo(Funding::class, 'source_funding_id'); }}
// ❌ BAD: Custom relationship handlingclass FundingRollover extends Model{ public function getSourceFunding() { return DB::table('fundings')->find($this->source_funding_id); }}3. Progress Over Perfection
Philosophy: Ship MVP, iterate based on feedback. Avoid analysis paralysis.
In Practice:
- Build V1 with core functionality
- Add manual overrides as escape hatches
- Use feature flags for gradual rollout
- Gather feedback, then enhance
Example:
V1: Automatic rollover calculationV2: Add manual override formV3: Add rollover forecastingV4: Add AI-powered recommendations4. Optimize for Happiness
Philosophy: Developer experience and user experience matter equally.
In Practice:
- Write readable, self-documenting code
- Use meaningful variable names
- Add helpful error messages
- Build intuitive UIs
- Minimize cognitive load
Example:
// ✅ GOOD: Clear, readable$rolloverAmount = max( $minimumRolloverAmount, $unusedAmount * $rolloverPercentage);
// ❌ BAD: Cryptic$ra = max($min, $ua * $rp);5. Code is Read More Than Written
Philosophy: Optimize for readability and maintainability.
In Practice:
- Use descriptive names (methods, classes, variables)
- Extract complex logic into well-named methods
- Add comments for “why,” not “what”
- Keep methods short (< 20 lines ideal)
- Single responsibility principle
Example:
// ✅ GOOD: Clear, self-documentingpublic function calculateRolloverAmount(float $unusedAmount): float{ $tenPercentOfUnused = $unusedAmount * 0.10; $minimumRollover = 1000;
return max($minimumRollover, $tenPercentOfUnused);}
// ❌ BAD: Requires mental parsingpublic function calc($amt): float{ return max(1000, $amt * 0.1);}6. Boring Technology
Philosophy: Use proven, stable tools. Avoid shiny object syndrome.
In Practice:
- Laravel core features (jobs, queues, events, cache)
- PostgreSQL (not experimental databases)
- Vue 3 (not bleeding-edge framework)
- Tailwind CSS (not custom CSS framework)
Example:
✅ GOOD: Laravel Jobs + Horizon❌ BAD: Custom queue system with Node.js worker7. Integrated Systems
Philosophy: Favor integration over isolation. Share state, leverage the framework.
In Practice:
- Use Laravel’s event system for decoupling
- Share models and relationships across domains
- Use database transactions for consistency
- Leverage Inertia for seamless frontend/backend integration
Example:
// ✅ GOOD: Integrated, leverages LaravelDB::transaction(function () use ($rollover) { $rollover->update(['status' => 'applied']); $funding->increment('rollover_added_amount', $rollover->amount); event(new RolloverApplied($rollover));});
// ❌ BAD: Isolated, custom logic$this->updateRolloverStatus($rollover->id, 'applied');$this->incrementFunding($funding->id, $rollover->amount);$this->sendRolloverEvent($rollover->id);Taylor Otwell Principles (Laravel Philosophy)
1. Elegant Syntax
Philosophy: Code should be beautiful and expressive.
In Practice:
- Use fluent interfaces
- Chain methods naturally
- Leverage Laravel’s helpers
- Use collections over raw arrays
Example:
// ✅ GOOD: Elegant, expressive$eligibleRollovers = Funding::query() ->where('funding_stream_id', $onFundingStream->id) ->whereHas('fundingQuarters', fn($q) => $q->where('quarter_id', $quarter->id)) ->with('fundingQuarters') ->get() ->filter(fn($funding) => $funding->unusedAmount() > 0) ->map(fn($funding) => [ 'funding' => $funding, 'unused_amount' => $funding->unusedAmount(), ]);
// ❌ BAD: Verbose, imperative$fundings = DB::select("SELECT * FROM fundings WHERE funding_stream_id = ?", [$id]);$result = [];foreach ($fundings as $funding) { $quarters = DB::select("SELECT * FROM funding_quarters WHERE funding_id = ?", [$funding->id]); $unused = $funding->total - $funding->used; if ($unused > 0) { $result[] = ['funding' => $funding, 'unused_amount' => $unused]; }}2. Developer Joy
Philosophy: Make developers’ lives easier and more enjoyable.
In Practice:
- Use Artisan commands for common tasks
- Leverage factories for testing
- Use migrations for schema changes
- Clear error messages with helpful hints
Example:
// ✅ GOOD: Helpful error messagethrow new RolloverException( "Cannot apply rollover: target funding not found for quarter {$targetQuarter->name}. " . "Please ensure funding has been synced from Services Australia.");
// ❌ BAD: Cryptic errorthrow new Exception("Rollover failed");3. Sensible Defaults
Philosophy: Provide smart defaults, allow customization.
In Practice:
- Default configuration in config files
- Override via environment variables
- Reasonable defaults that work for 80% of cases
Example:
return [ 'enabled' => env('FUNDING_ROLLOVER_ENABLED', true), 'minimum_amount' => env('FUNDING_ROLLOVER_MINIMUM', 1000), 'rollover_percentage' => env('FUNDING_ROLLOVER_PERCENTAGE', 0.10), 'days_after_quarter_end' => env('FUNDING_ROLLOVER_DAYS_AFTER', 1),];4. Expressive ORMs
Philosophy: Eloquent should feel natural, like talking to the database.
In Practice:
- Use Eloquent relationships
- Define scopes for common queries
- Use accessors/mutators for computed properties
- Eager load to avoid N+1
Example:
// ✅ GOOD: Expressive, uses Eloquentclass FundingRollover extends Model{ public function scopePending(Builder $query): void { $query->where('status', 'pending'); }
public function isPending(): bool { return $this->status === 'pending'; }}
$pendingRollovers = FundingRollover::pending() ->with(['sourceFunding', 'targetQuarter']) ->get();
// ❌ BAD: Raw SQL, loses Eloquent benefits$rollovers = DB::select(" SELECT * FROM funding_rollovers WHERE status = 'pending'");5. Testing is First-Class
Philosophy: Testing should be easy and encouraged.
In Practice:
- Use factories for test data
- Parallel testing with Pest or PHPUnit
- Clear test names that describe behavior
- Test-driven development when appropriate
Example:
// ✅ GOOD: Clear, descriptive test/** @test */public function rollover_applies_minimum_of_1000_when_10_percent_is_less(){ $funding = Funding::factory()->create([ 'services_australia_total_amount' => 10000, ]); $funding->fundingQuarters()->create([ 'quarter_id' => 1, 'total_amount' => 10000, 'used_services_amount' => 8000, 'used_fees_amount' => 0, ]);
$action = new CalculateFundingRolloverAction(); $result = $action->execute($funding, Quarter::find(1), 2000);
$this->assertEquals(1000, $result['rollover_amount']); $this->assertTrue($result['is_minimum_applied']);}
// ❌ BAD: Unclear testpublic function test_rollover(){ $r = $this->calc(2000); $this->assertEquals(1000, $r);}6. Purposeful Abstractions
Philosophy: Abstract when it adds clarity, not for its own sake.
In Practice:
- Action classes for complex business logic
- DTOs (Laravel Data) for structured data
- Services for cross-domain logic
- Avoid over-abstraction
Example:
// ✅ GOOD: Purposeful abstractionclass CreateFundingRolloverAction{ public function execute(CreateFundingRolloverData $data): FundingRollover { return FundingRollover::create([ 'uuid' => Str::uuid(), 'package_id' => $data->packageId, 'source_funding_id' => $data->sourceFundingId, 'target_quarter_id' => $data->targetQuarterId, 'unused_amount' => $data->unusedAmount, 'rollover_amount' => $data->rolloverAmount, 'status' => 'pending', ]); }}
// ❌ BAD: Over-abstractioninterface RolloverCreatorInterface { ... }class RolloverCreatorFactory { ... }class AbstractRolloverCreator implements RolloverCreatorInterface { ... }class ConcreteRolloverCreator extends AbstractRolloverCreator { ... }Laravel Data Patterns
Use Laravel Data for:
- Request validation and transformation
- Structured DTOs passed to actions
- API responses
- Type-safe data transfer
Pattern
use Spatie\LaravelData\Data;
class CreateRolloverData extends Data{ public function __construct( public int $packageId, public int $sourceFundingId, public int $sourceQuarterId, public int $targetQuarterId, public float $unusedAmount, public float $rolloverAmount, public string $triggerType, public ?int $triggeredBy, public ?string $notes, ) {}
public static function rules(): array { return [ 'packageId' => ['required', 'exists:packages,id'], 'sourceFundingId' => ['required', 'exists:fundings,id'], 'sourceQuarterId' => ['required', 'exists:quarters,id'], 'targetQuarterId' => ['required', 'exists:quarters,id'], 'unusedAmount' => ['required', 'numeric', 'min:0'], 'rolloverAmount' => ['required', 'numeric', 'min:0'], 'triggerType' => ['required', 'in:automatic,manual,sync'], 'triggeredBy' => ['nullable', 'exists:users,id'], 'notes' => ['nullable', 'string'], ]; }}
// Usage in controllerpublic function store(CreateRolloverRequest $request, CreateRolloverAction $action){ $rollover = $action->execute( CreateRolloverData::from($request) );
return response()->json($rollover, 201);}Event Sourcing Patterns
When to Use Event Sourcing
- Financial transactions (audit trail required)
- State changes that need to be replayed
- Compliance and regulatory requirements
- Complex business logic with multiple states
Pattern
// Aggregate Rootclass FundingAggregateRoot extends AggregateRoot{ public function addRollover(float $amount, int $sourceQuarterId): self { $this->recordThat(new FundingRolloverAdded( amount: $amount, sourceQuarterId: $sourceQuarterId, timestamp: now() ));
return $this; }
protected function applyFundingRolloverAdded(FundingRolloverAdded $event): void { $this->rolloverAddedAmount += $event->amount; }}
// Usage$aggregate = FundingAggregateRoot::retrieve($funding->external_id);$aggregate->addRollover(1000, $quarter->id)->persist();UI/UX Patterns
Inertia.js + Vue 3
- Server-side data passing (no API calls from frontend)
- Form handling with Inertia form helpers
- Shared layouts
- TypeScript for type safety
Pattern
// Page component<script setup lang="ts">import { useForm } from '@inertiajs/vue3'
interface Props { rollover: Rollover funding: Funding}
const props = defineProps<Props>()
const form = useForm({ rollover_amount: props.rollover.suggested_amount, notes: '',})
const submit = () => { form.post(route('rollovers.store'), { onSuccess: () => { // Handle success }, })}</script>
<template> <form @submit.prevent="submit"> <input v-model="form.rollover_amount" type="number" /> <textarea v-model="form.notes"></textarea> <button :disabled="form.processing">Create Rollover</button> </form></template>Accessibility First
MUST Follow
- Full keyboard navigation (per WAI-ARIA APG)
- Visible focus indicators
- Semantic HTML (
<button>,<a>,<label>) - ARIA labels for icon-only buttons
- Color + icon/text (not color alone)
- Meet WCAG 2.1 Level AA contrast
Pattern
<!-- ✅ GOOD: Accessible --><button type="button" @click="openModal" aria-label="Create manual rollover" class="focus:ring-2 focus:ring-blue-500"> <PlusIcon class="h-5 w-5" aria-hidden="true" /> <span>Create Rollover</span></button>
<!-- ❌ BAD: Not accessible --><div @click="openModal" class="cursor-pointer"> <PlusIcon /></div>Performance Principles
Database
- Index foreign keys and frequently queried columns
- Eager load relationships (avoid N+1)
- Use database transactions for consistency
- Chunk large queries
Queues
- Queue long-running jobs (> 1 second)
- Use Horizon for monitoring
- Implement job retries with exponential backoff
Caching
- Cache static data (funding streams, quarters)
- Use cache tags for group invalidation
- Cache computed values (funding utilization)
Frontend
- Lazy load images
- Virtualize long lists
- Optimize re-renders (Vue reactivity)
Testing Strategy
What to Test
- Unit Tests: Actions, calculations, business logic
- Feature Tests: API endpoints, form submissions, workflows
- Integration Tests: Jobs, event sourcing, external APIs
- E2E Tests: Critical user flows
Coverage Target
- > 90% for business logic (actions, models)
- > 80% overall
Pattern
// Unit testclass CalculateFundingRolloverActionTest extends TestCase{ /** @test */ public function it_applies_minimum_rollover_of_1000() { $action = new CalculateFundingRolloverAction();
$result = $action->execute( funding: Funding::factory()->make(), sourceQuarter: Quarter::factory()->make(), unusedAmount: 2000 );
$this->assertEquals(1000, $result['rollover_amount']); $this->assertTrue($result['is_minimum_applied']); }}
// Feature testclass CreateRolloverTest extends TestCase{ use RefreshDatabase;
/** @test */ public function care_coordinator_can_create_manual_rollover() { $user = User::factory()->careCoordinator()->create(); $package = Package::factory()->create(); $funding = Funding::factory()->for($package)->create();
$response = $this->actingAs($user) ->postJson(route('rollovers.store'), [ 'package_id' => $package->id, 'source_funding_id' => $funding->id, 'rollover_amount' => 1500, 'notes' => 'Participant was hospitalized', ]);
$response->assertCreated(); $this->assertDatabaseHas('funding_rollovers', [ 'package_id' => $package->id, 'rollover_amount' => 1500, 'trigger_type' => 'manual', ]); }}Documentation Principles
Code Comments
- Comment “why,” not “what”
- Add comments for non-obvious business rules
- Use PHPDoc for method signatures
User Documentation
- User guides for care coordinators
- Admin guides for configuration
- API documentation (OpenAPI/Swagger)
- FAQ for common questions
Technical Documentation
- Architecture diagrams (when helpful)
- Sequence diagrams for complex flows
- Database schema documentation
- Decision records (ADRs) for major decisions
Gradual Rollout Strategy
Always Use Feature Flags for:
- New features with business impact
- Changes to critical workflows
- Features that affect many users
Rollout Stages
- Dev/Staging: Full testing
- Beta (10%): Selected users, daily monitoring
- Staged (50%): Broader group, continue monitoring
- Full (100%): All users, monitor for one release cycle
Rollback Plan
- Feature flag can disable instantly
- Database migrations reversible
- Clear rollback criteria defined
Error Handling
Principles
- Fail loudly in dev, gracefully in prod
- Log errors with context
- User-friendly error messages
- Provide recovery options
Pattern
try { $rollover = $this->applyRolloverAction->execute($rollover);} catch (TargetFundingNotFoundException $e) { Log::error('Rollover application failed: target funding not found', [ 'rollover_uuid' => $rollover->uuid, 'target_quarter_id' => $rollover->target_quarter_id, 'exception' => $e, ]);
return back()->with('error', 'Cannot apply rollover: funding for the next quarter has not been created yet. ' . 'Please sync funding from Services Australia first.' );}Security Principles
Authorization
- Use policies for model-level authorization
- Use gates for feature-level authorization
- Never trust user input
Input Validation
- Always validate with Laravel Data or Form Requests
- Sanitize outputs (XSS prevention)
- Use prepared statements (Eloquent does this)
Pattern
// Policyclass FundingRolloverPolicy{ public function create(User $user, Package $package): bool { return $user->can('manage-package', $package) && $user->hasRole(['care_coordinator', 'admin']); }
public function cancel(User $user, FundingRollover $rollover): bool { return $rollover->isPending() && $user->hasRole('admin'); }}
// Controllerpublic function cancel(FundingRollover $rollover){ $this->authorize('cancel', $rollover);
$rollover->update(['status' => 'cancelled']);
return redirect()->back()->with('success', 'Rollover cancelled.');}Monitoring and Observability
What to Monitor
- Job success/failure rates
- API response times
- Database query performance
- Business metrics (rollover amounts, success rates)
Tools
- Laravel Log (daily rotation)
- Sentry (error tracking)
- Laravel Horizon (queue monitoring)
- Custom metrics dashboard
Pattern
// Log with contextLog::info('Rollover applied', [ 'rollover_uuid' => $rollover->uuid, 'package_id' => $rollover->package_id, 'rollover_amount' => $rollover->rollover_amount, 'applied_at' => $rollover->applied_at,]);
// Custom metricMetrics::increment('funding_rollovers_applied', [ 'trigger_type' => $rollover->trigger_type, 'amount_category' => $this->categorizeAmount($rollover->rollover_amount),]);Summary Checklist
When planning a new feature, ensure:
- Fits within the majestic monolith (no microservices)
- Follows Laravel conventions
- Uses Laravel Data for DTOs
- Event sourcing for audit-critical logic
- Action classes for business logic
- Accessible UI (WCAG 2.1 AA)
- Performance optimized (indexes, eager loading, queues)
- Comprehensive tests (>90% coverage for logic)
- User documentation planned
- Feature flag for gradual rollout
- Authorization via policies/gates
- Error handling with helpful messages
- Monitoring and logging in place
These principles should guide every feature decision in the TC Portal.